/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.dev/license
 */

import {Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {applyWhenValue, debounce, form} from '@angular/forms/signals';

describe('debounce', () => {
  describe('by duration', () => {
    it('should synchronize value immediately if non-positive', () => {
      const address = signal({street: ''});
      const addressForm = form(
        address,
        (address) => {
          debounce(address.street, 0);
        },
        options(),
      );
      const street = addressForm.street();

      street.setControlValue('1600 Amphitheatre Pkwy');
      expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
      expect(street.value()).toBe('1600 Amphitheatre Pkwy');
    });

    it('should synchronize value after duration', async () => {
      const address = signal({street: ''});
      const addressForm = form(
        address,
        (address) => {
          debounce(address.street, 1);
        },
        options(),
      );
      const street = addressForm.street();

      street.setControlValue('1600 Amphitheatre Pkwy');
      expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
      expect(street.value()).toBe('');

      await timeout(0);
      expect(street.value()).toBe('1600 Amphitheatre Pkwy');
    });

    it('should synchronize value immediately on touch', () => {
      const address = signal({street: ''});
      const addressForm = form(
        address,
        (address) => {
          debounce(address.street, 1);
        },
        options(),
      );
      const street = addressForm.street();

      street.setControlValue('1600 Amphitheatre Pkwy');
      expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
      expect(street.value()).toBe('');

      street.markAsTouched();
      expect(street.value()).toBe('1600 Amphitheatre Pkwy');
    });
  });

  describe('by function', () => {
    it('should synchronize value immediately by default', () => {
      const address = signal({street: ''});
      const addressForm = form(
        address,
        (address) => {
          debounce(address.street, () => {});
        },
        options(),
      );
      const street = addressForm.street();

      street.setControlValue('1600 Amphitheatre Pkwy');
      expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
      expect(street.value()).toBe('1600 Amphitheatre Pkwy');
    });

    it('should synchronize value immediately on touch', () => {
      const address = signal({street: ''});
      const addressForm = form(
        address,
        (address) => {
          debounce(address.street, forever);
        },
        options(),
      );
      const street = addressForm.street();

      street.setControlValue('1600 Amphitheatre Pkwy');
      expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
      expect(street.value()).toBe('');

      street.markAsTouched();
      expect(street.value()).toBe('1600 Amphitheatre Pkwy');
    });

    it('should synchronize value after promise resolves', async () => {
      const {promise, resolve} = promiseWithResolvers<void>();
      const address = signal({street: ''});
      const addressForm = form(
        address,
        (address) => {
          debounce(address.street, () => promise);
        },
        options(),
      );
      const street = addressForm.street();

      street.setControlValue('1600 Amphitheatre Pkwy');
      expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
      expect(street.value()).toBe('');

      resolve();
      await promise;

      expect(street.value()).toBe('1600 Amphitheatre Pkwy');
    });

    it('should synchronize value after most recently returned promise resolves', async () => {
      const first = promiseWithResolvers();
      const second = promiseWithResolvers();
      const debounceFn = jasmine
        .createSpy('debounceFn')
        .and.returnValues(first.promise, second.promise);

      const address = signal({street: ''});
      const addressForm = form(
        address,
        (address) => {
          debounce(address.street, debounceFn);
        },
        options(),
      );
      const street = addressForm.street();

      street.setControlValue('1600 Amphitheatre Pkwy');
      expect(street.value()).toBe('');

      street.setControlValue('2000 N Shoreline Blvd');
      expect(street.value()).toBe('');

      first.resolve();
      await first.promise;
      expect(street.value()).toBe('');

      second.resolve();
      await second.promise;
      expect(street.value()).toBe('2000 N Shoreline Blvd');
    });

    it('should be ignored if value is directly set before it resolves', async () => {
      const debounceResult = promiseWithResolvers();
      const debounceFn = jasmine.createSpy('debounceFn').and.returnValues(debounceResult.promise);

      const address = signal({street: ''});
      const addressForm = form(
        address,
        (address) => {
          debounce(address.street, debounceFn);
        },
        options(),
      );
      const street = addressForm.street();

      // Set `controlValue` which will trigger a debounce update.
      street.setControlValue('1600 Amphitheatre Pkwy');
      expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
      expect(street.value()).toBe('');

      // Directly set value during debounce duration.
      street.value.set('2000 N Shoreline Blvd');
      expect(street.value()).toBe('2000 N Shoreline Blvd');
      expect(street.controlValue()).toBe('2000 N Shoreline Blvd');

      // Wait for the debounced update, which should be ignored.
      await debounceResult.resolve;
      expect(street.value()).toBe('2000 N Shoreline Blvd');
    });

    describe('abort signal', () => {
      it('should be aborted if control value is set again', async () => {
        const {promise, resolve} = promiseWithResolvers();
        const abortSpy = jasmine.createSpy('abort');

        const address = signal({street: ''});
        const addressForm = form(
          address,
          (address) => {
            debounce(address.street, (_context, signal) => {
              signal.addEventListener('abort', abortSpy);
              return promise;
            });
          },
          options(),
        );
        const street = addressForm.street();

        street.setControlValue('1600 Amphitheatre Pkwy');
        expect(abortSpy).not.toHaveBeenCalled();

        street.setControlValue('2000 N Shoreline Blvd');
        expect(abortSpy).toHaveBeenCalledTimes(1);

        resolve();
        await promise;
        expect(street.value()).toBe('2000 N Shoreline Blvd');
      });

      it('should be aborted on touch', async () => {
        const abortSpy = jasmine.createSpy('abort');
        const address = signal({street: ''});
        const addressForm = form(
          address,
          (address) => {
            debounce(address.street, (_context, signal) => {
              signal.addEventListener('abort', abortSpy);
              return forever();
            });
          },
          options(),
        );
        const street = addressForm.street();

        street.setControlValue('1600 Amphitheatre Pkwy');
        expect(abortSpy).not.toHaveBeenCalled();

        street.markAsTouched();
        expect(abortSpy).toHaveBeenCalledTimes(1);
        expect(street.value()).toBe('1600 Amphitheatre Pkwy');
      });
    });
  });

  describe('inheritance', () => {
    it('should inherit debounce from parent', async () => {
      const address = signal({street: ''});
      const addressForm = form(
        address,
        (address) => {
          debounce(address, 1);
        },
        options(),
      );
      const street = addressForm.street();

      street.setControlValue('1600 Amphitheatre Pkwy');
      expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
      expect(street.value()).toBe('');

      await timeout(0);
      expect(street.value()).toBe('1600 Amphitheatre Pkwy');
    });

    it('can override inherited debounce', async () => {
      const address = signal({street: ''});
      const addressForm = form(
        address,
        (address) => {
          debounce(address, 1);
          debounce(address.street, 0);
        },
        options(),
      );
      const street = addressForm.street();

      street.setControlValue('1600 Amphitheatre Pkwy');
      expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
      expect(street.value()).toBe('1600 Amphitheatre Pkwy');
    });

    it(`should not affect parent's debounce`, async () => {
      const address = signal({street: ''});
      const addressForm = form(
        address,
        (address) => {
          debounce(address, 1);
          debounce(address.street, 0);
        },
        options(),
      );

      addressForm().setControlValue({street: '1600 Amphitheatre Pkwy'});
      expect(addressForm().controlValue()).toEqual({street: '1600 Amphitheatre Pkwy'});
      expect(addressForm().value()).toEqual({street: ''});

      await timeout(0);
      expect(addressForm().value()).toEqual({street: '1600 Amphitheatre Pkwy'});
    });

    it(`should not affect a sibling's debounce`, async () => {
      const address = signal({street: '', city: ''});
      const addressForm = form(
        address,
        (address) => {
          debounce(address.street, 1);
        },
        options(),
      );

      addressForm.street().setControlValue('1600 Amphitheatre Pkwy');
      expect(addressForm().value()).toEqual({street: '', city: ''});

      addressForm.city().setControlValue('Mountain View');
      expect(addressForm().value()).toEqual({street: '', city: 'Mountain View'});

      await timeout(0);
      expect(addressForm().value()).toEqual({
        street: '1600 Amphitheatre Pkwy',
        city: 'Mountain View',
      });
    });
  });

  describe('aggregation', () => {
    it('should apply the last debounce rule', () => {
      const address = signal({street: '', city: ''});
      const addressForm = form(
        address,
        (address) => {
          debounce(address.street, 1);
          debounce(address.street, 0);
        },
        options(),
      );
      const street = addressForm.street();

      street.setControlValue('1600 Amphitheatre Pkwy');
      expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
      expect(street.value()).toBe('1600 Amphitheatre Pkwy');
    });

    it('should apply the last debounce rule from schemas', async () => {
      const address = signal({street: '', city: ''});
      const schema1 = (address: any) => {
        debounce(address.street, 0);
      };
      const schema2 = (address: any) => {
        debounce(address.street, 1);
      };
      const addressForm = form(
        address,
        (address) => {
          schema1(address);
          schema2(address);
        },
        options(),
      );
      const street = addressForm.street();

      street.setControlValue('1600 Amphitheatre Pkwy');
      expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
      expect(street.value()).toBe('');

      await timeout(0);
      expect(street.value()).toBe('1600 Amphitheatre Pkwy');
    });

    it('should apply the last debounce rule from conditional schemas', async () => {
      const address = signal({street: '', city: ''});
      const debounced = signal(false);
      const addressForm = form(
        address,
        (address) => {
          applyWhenValue(
            address,
            () => debounced(),
            (address) => {
              debounce(address.street, 0);
            },
          );
          applyWhenValue(
            address,
            () => debounced(),
            (address) => {
              debounce(address.street, 1);
            },
          );
        },
        options(),
      );
      const street = addressForm.street();

      street.setControlValue('1600 Amphitheatre Pkwy');
      expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
      expect(street.value()).toBe('1600 Amphitheatre Pkwy');

      debounced.set(true);
      street.setControlValue('2000 N Shoreline Blvd');
      expect(street.controlValue()).toBe('2000 N Shoreline Blvd');
      expect(street.value()).toBe('1600 Amphitheatre Pkwy');

      await timeout(0);
      expect(street.value()).toBe('2000 N Shoreline Blvd');
    });

    it('should apply debounce rule conditionally', async () => {
      const address = signal({street: '', city: ''});
      const debounced = signal(true);
      const addressForm = form(
        address,
        (address) => {
          applyWhenValue(
            address.street,
            () => debounced(),
            (street) => {
              debounce(street, 1);
            },
          );
        },
        options(),
      );
      const street = addressForm.street();

      street.setControlValue('1600 Amphitheatre Pkwy');
      expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
      expect(street.value()).toBe('');

      await timeout(0);
      expect(street.value()).toBe('1600 Amphitheatre Pkwy');

      debounced.set(false);
      street.setControlValue('2000 N Shoreline Blvd');
      expect(street.value()).toBe('2000 N Shoreline Blvd');
    });
  });
});

/** Options for testing. */
function options() {
  return {injector: TestBed.inject(Injector)};
}

/** Returns a promise that will resolve after {@link durationInMilliseconds}.  */
function timeout(durationInMilliseconds: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, durationInMilliseconds));
}

/** Returns a promise that will never resolve. */
function forever(): Promise<never> {
  return new Promise(() => {});
}

/**
 * Replace with `Promise.withResolvers()` once it's available.
 *
 * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers.
 */
// TODO: share this with submit.spec.ts
function promiseWithResolvers<T = void>(): {
  promise: Promise<T>;
  resolve: (value: T | PromiseLike<T>) => void;
  reject: (reason?: any) => void;
} {
  let resolve!: (value: T | PromiseLike<T>) => void;
  let reject!: (reason?: any) => void;

  const promise = new Promise<T>((res, rej) => {
    resolve = res;
    reject = rej;
  });

  return {promise, resolve, reject};
}
